Auth0で保護されたAWS AppSync(GraphQL)をReactからApollo Clientで利用する方法をチュートリアルとしてまとめた
はじめに
おはようございます、加藤です。最近、初めのフロントエンド開発を行っておりReactを使い始めて1ヶ月が立ちました。 今回はReactからAuth0(Open ID Connect)で保護されている、AppSync(GraphQL)をApollo Clientで利用する方法をご紹介します。
やってみた
出来上がるサンプルアプリケーションのコードはこちらです。
intercept6/react-app-sync-auth0-tutorial: React & AppSync(GraphQL) & Apollo Client サンプルアプリケーション
AppSyncの構築&Auth0の設定
最初に下記のブログを参考にAppSync(GraphQL)の構築とAuth0にAPIを定義します。
AWS AppSyncでAuth0を認証プロバイダーとしたOIDCを設定する | Developers.IO
ここで自動で定義されているApplicationはMachine to Machine用(OAuth 2.0 Client Credentials Grant)の認可なのでReactでユーザーを認証する事はできません。Single Page Web Application用を作成します。
作成はAuth0のDashbordから行います。
CREATE APPLICATIONを選択します。
Nameに任意の名前(ここでは、AppSyncAuth0(Test SPA))を入力し、Chose an application typeにSingle Page Web Applicationsを選択し、CREATEを選択します。
Settingsタブを選択し、Domain、Client ID、Client Secretをメモしておきます。
Settingsタブで下記の項目を設定する。
項目名 | 設定する値 | 説明 |
---|---|---|
Allowed Callback URLs | http://localhost:3000 | Auth0での認証後にリダイレクトを許可するURLリスト(カンマ区切り)開発中のlocalhostを除いて必ずHTTPSにする必要があります。 |
Allowed Logout URLs | http://localhost:3000 | Auth0での認証後にリダイレクトを許可するURLリスト(カンマ区切り) |
Allowed Web Origins | http://localhost:3000 | ブラウザからのTokenする際にAuth0のAPIへアクセスする必要がありこれはクロスオリジンとなる。このCORS対応の為にドメインを登録する必要があります。 |
サンプルアプリ(React)の構築 パート1
パート1ではAuth0にログインが出来るところまで構築します。
Node.js、NPM、Yarnが端末にセットアップされていない場合は、下記などを参考にセットアップしてください。
各ツールのバージョンは下記の表を確認してください。
名称 | バージョン |
---|---|
Node.js | 12.16.1 |
NPM | 6.13.4 |
Yarn | 1.22.4 |
Create React App | 3.4.1 |
Create React App(CRA)を使ってアプリケーションを生成します。任意の作業用ディレクトリに移動後、下記のコマンドを実行します。
再現性を高めるために、パッケージのバージョンを固定してインストールしています。
npx create-react-app@3.4.1 --template typescript react-appsync-protected-by-auth0 cd react-appsync-protected-by-auth0
React RouterとAuth0のSPA用SDKをインストールします。
yarn add react-router-dom@5.1.2 @auth0/auth0-spa-js@1.8.1 yarn add -D @types/react-router-dom@5.1.2
Auth0をReactで利用する為のCustom Hookを作成します。
import React from 'react'; import createAuth0Client, { Auth0Client, Auth0ClientOptions, getIdTokenClaimsOptions, GetTokenSilentlyOptions, GetTokenWithPopupOptions, IdToken, LogoutOptions, PopupConfigOptions, PopupLoginOptions, RedirectLoginOptions, } from '@auth0/auth0-spa-js'; type Auth0ContextOptions = { isAuthenticated: boolean; user: any; loading: boolean; popupOpen: boolean; loginWithPopup: (options?: PopupLoginOptions, config?: PopupConfigOptions) => Promise<void>; handleRedirectCallback: (path?: string) => Promise<void>; getIdTokenClaims: (options?: getIdTokenClaimsOptions) => Promise<IdToken>; loginWithRedirect: (options?: RedirectLoginOptions) => Promise<void>; getTokenSilently: (options?: GetTokenSilentlyOptions) => Promise<any>; getTokenWithPopup: (options?: GetTokenWithPopupOptions, config?: PopupConfigOptions) => Promise<string>; logout: (options?: LogoutOptions) => void; } export type Auth0ProviderOptions = Auth0ClientOptions & { children: React.ReactElement; onRedirectCallback: Auth0ContextOptions['handleRedirectCallback']; } export const Auth0Context = React.createContext({} as Auth0ContextOptions); export const useAuth0 = () => React.useContext<Auth0ContextOptions>(Auth0Context); export const Auth0Provider: React.FC<Auth0ProviderOptions> = ( { children, onRedirectCallback, ...initOptions } ) => { const [isAuthenticated, setIsAuthenticated] = React.useState<boolean>(false); const [user, setUser] = React.useState<any>(null); const [auth0Client, setAuth0] = React.useState<Auth0Client>(); const [loading, setLoading] = React.useState<boolean>(true); const [popupOpen, setPopupOpen] = React.useState<boolean>(false); React.useEffect(() => { const initAuth0 = async () => { const auth0FromHook = await createAuth0Client(initOptions); setAuth0(auth0FromHook); if (window.location.search.includes('code=') && window.location.search.includes('state=')) { const {appState} = await auth0FromHook.handleRedirectCallback(); await onRedirectCallback(appState?.targetUrl); } const isAuthenticated = await auth0FromHook.isAuthenticated(); setIsAuthenticated(isAuthenticated); if (isAuthenticated) { const user = await auth0FromHook.getUser(); setUser(user); } setLoading(false); }; initAuth0(); // eslint-disable-next-line }, []); const loginWithPopup: Auth0ContextOptions['loginWithPopup'] = async (options, config) => { setPopupOpen(true); try { await auth0Client!.loginWithPopup(options, config); } catch (error) { console.error(error); } finally { setPopupOpen(false); } const user = await auth0Client!.getUser(); setUser(user); setIsAuthenticated(true); }; const handleRedirectCallback: Auth0ContextOptions['handleRedirectCallback'] = async (url) => { setLoading(true); await auth0Client!.handleRedirectCallback(url); const user = await auth0Client!.getUser(); setLoading(false); setIsAuthenticated(true); setUser(user); }; return ( <Auth0Context.Provider value={{ isAuthenticated, user, loading, popupOpen, loginWithPopup, handleRedirectCallback, getIdTokenClaims: (options) => auth0Client!.getIdTokenClaims(options), loginWithRedirect: (options) => auth0Client!.loginWithRedirect(options), getTokenSilently: (options) => auth0Client!.getTokenSilently(options), getTokenWithPopup: (options, config) => auth0Client!.getTokenWithPopup(options, config), logout: (options) => auth0Client!.logout(options) }} > {children} </Auth0Context.Provider> ); };
ログイン/ログアウト操作を行わせる為にNavBarを作成します。
import React from "react"; import { useAuth0 } from "../react-auth0-spa"; export const NavBar = () => { const { isAuthenticated, loginWithRedirect, logout } = useAuth0(); return ( <div> {!isAuthenticated && ( <button onClick={() => loginWithRedirect({})}>Log in</button> )} {isAuthenticated && <button onClick={() => logout()}>Log out</button>} </div> ); };
history
を生成し、どこからでもアクセスが出来るようにします。useHistory()
Hookを使わずに、createBrowserHistory()
を使っているのは、前者はRouteコンポーネント配下でしか使えないが、後者はどこでも使える(少なくともpushの定義は出来る)からです。しかし、この部分はしっかりとは理解できていないです。。。
import { createBrowserHistory } from "history"; export const history = createBrowserHistory();
Auth0の設定をアプリケーションが取り込めるようにJSON形式で保存します。先程作成した、Auth0のApplication定義のSettingsタブからDomainとClient IDを転記してください。
{ "domain": "YOUR_DOMAIN", "clientId": "YOUR_CLIENT_ID" }
今回はサンプルアプリケーションなので、環境(dev/stg/prd)差分を考慮せずパラメーターをハードコートしています。ハードコート、ダメ絶対。
作成したAuth0 Custom Hookをアプリケーションに結合させる為に、index.tsx
を編集します。
import React from 'react'; import ReactDOM from 'react-dom'; import { App } from './App'; import * as serviceWorker from './serviceWorker'; import { history } from './utils/history'; import { Auth0Provider } from "./react-auth0-spa"; import authConfig from "./auth_config.json"; const onRedirectCallback = async (url?: string) => { history.push(url ?? window.location.pathname); }; ReactDOM.render( <React.StrictMode> <Auth0Provider domain={authConfig.domain} client_id={authConfig.clientId} redirect_uri={window.location.origin} onRedirectCallback={onRedirectCallback} > <App /> </Auth0Provider> </React.StrictMode>, document.getElementById('root') ); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister();
CRAで生成されたCSS等使わないので削除してしまいましょう。
- logo.svg
- index.tsx
- index.css
- App.css
- App.test.tsx
パート1はここまでです。アプリケーションを実行して、ログインが出来るか確認します。
まず、テスト用のユーザーをAuth0に作成します。Auth0のDashboardを開き、Users & Roles、Usersとメニューを選択し、CREATE USERを選択します。
ユーザーのEmailとPasswordを入力し、CREATEを選択します。
ベリファイのメールが届くのでリンクをクリックして、ユーザーを使用可能な状態に遷移させます。
ユーザーの準備ができたので、アプリケーションを実行しログインが行えるか確認します。下記のコマンドで実行すると、デフォルトブラウザで http://loclahost:3000 が開きます。自動でブラウザが開かない場合は手動でURLを入力してアクセスしてください。
yarn start
ブラウザで素朴な画面が表示されるので、Log inボタンを選択します。
Auth0のユニバーサルログイン画面にリダイレクトされるので、テストユーザーのEmailとPasswordを入力し、LOG INを選択します。
アプリケーションに対する認可を確認されるのでチェックアイコンを選択します。アプリケーションにアイコンを設定していないので、画像が表示されていませんね。
一瞬、Loading...と表示された後に、Log outボタンだけの素朴な画面に戻れば成功です。
Log outを選択してログアウトしておきます。以降もアプリケーションを編集する前にはログアウトしてから行います。特にパート4でaudienceを設定する際には事前にログアウトが必須です。
サンプルアプリ(React)の構築 パート2
パート2ではID Tokenの情報を表示するプロフィール画面を作成します。ID Tokenから情報を表示するのでログイン済みである必要があります。これに対応する為に、ログインしていなければAuth0のユニバーサルログイン画面へリダイレクトするPrivateRoute
コンポーネントを作成します。
プロフィール画面を作成します。Auth0 Custom Hookからユーザー情報を取得し、表示します。
import React from "react"; import { useAuth0 } from "../react-auth0-spa"; export const Profile = () => { const { loading, user } = useAuth0(); // FIXME: Private Routeで保護した後はこの処理は不要、後で削除する if (loading || !user) { return <div>Loading...</div>; } return ( <> <img src={user.picture} alt="Profile" /> <h2>{user.name}</h2> <p>{user.email}</p> <code>{JSON.stringify(user, null, 2)}</code> </> ); };
NavBar.tsx
を変更し、Profile画面に移動できるようにします。
import React from 'react'; import { useAuth0 } from '../react-auth0-spa'; import { Link } from 'react-router-dom'; export const NavBar = () => { const {isAuthenticated, loginWithRedirect, logout} = useAuth0(); return ( <div> {!isAuthenticated && ( <button onClick={() => loginWithRedirect()}>Log in</button> )} {isAuthenticated && ( <> <button onClick={() => logout()}>Log out</button> <span> <Link to="/">Home</Link> | <Link to="/profile">Profile</Link> </span> </> )} </div> ); };
App.tsx
を変更しProfile画面へのルーティングを定義します。
import React from "react"; import { NavBar } from "./components/NavBar"; import { Router, Route, Switch } from "react-router-dom"; import { Profile } from "./components/Profile"; import { history } from "./utils/history"; export const App = () => { return ( <div className="App"> <Router history={history}> <header> <NavBar /> </header> <Switch> <Route path="/" exact /> <Route path="/profile" component={Profile} /> </Switch> </Router> </div> ); };
まだ、リダイレクトが設定されていないので、下記のURLへアクセスするとLoading状態のまま遷移しません。
PrivateRoute
コンポーネントを作成します。このコンポーネントはReact RouterのRoute
コンポーネントのWrapperでログインしていなければ、ユニバーサルログイン画面にリダイレクトします。
import React from 'react'; import { Route, RouteProps } from 'react-router-dom'; import { useAuth0 } from '../react-auth0-spa'; export const PrivateRoute: React.FC<RouteProps> = ({ component: Component, path, ...rest }) => { const { loading, isAuthenticated, loginWithRedirect } = useAuth0(); React.useEffect(() => { if (loading || isAuthenticated) { return; } const fn = async () => { await loginWithRedirect({ appState: {targetUrl: window.location.pathname} }); }; fn(); }, [loading, isAuthenticated, loginWithRedirect, path]); const render: RouteProps['render'] = props => { if (isAuthenticated && Component != null) { return <Component {...props} />; } return null; }; return <Route path={path} render={render} {...rest} />; };
App.tsx
を変更して、Profile画面をPrivate Routeで保護します。
import React from "react"; import { NavBar } from "./components/NavBar"; import { Router, Route, Switch } from "react-router-dom"; import { Profile } from "./components/Profile"; import { history } from "./utils/history"; import { PrivateRoute } from './components/PrivateRoute'; export const App = () => { return ( <div className="App"> <Router history={history}> <header> <NavBar /> </header> <Switch> <Route path="/" exact /> <PrivateRoute path="/profile" component={Profile} /> </Switch> </Router> </div> ); };
パート2はここまでです。アプリケーションを実行して、ログアウト状態でProfile画面にアクセスしようとするとリダイレクトされるか確認します。ログアウトした状態で下記のURLへアクセスします。
Auth0のユニバーサルログイン画面へリダイレクトされるので、EmailとPasswordを入力します。
Profile画面が表示されます。表示を確認できたら、ログアウトしておきます。
サンプルアプリ(React)の構築 パート3
パート3ではAppSync Schema(GraphQL Schema)から型とReact Custom Hookを生成します。
一度セットアップしてしまえば、スクリプトを実行するだけでSchemaを元に何度でも再生成が可能です。
AWS CLIを使ってSchemaを取得します。後ほど気づきましたが、GraphQLエンドポイントに対してコードジェネレーターが直接Schemaをリクエストする方が一般的な様でした。しかし、この方法でも特に困らなさそうかつAWS CLIの認証を使えて楽なのでこの方法で行きます。AWS CLIがセットアップされていない場合は、下記などを参考にセットアップしてください。
mkdir -p src/graphql && \ aws appsync get-introspection-schema --api-id ${APP_SYNC_API_ID} --format SDL --include-directives src/graphql/schema.graphql
下記は取得したSchemaです。AppSyncでAPI作成時に利用できるサンプルプロジェクト(Event App)で作成されたSchemaから何も変更していないです。
schema { query: Query mutation: Mutation subscription: Subscription } type Comment { # A unique identifier for the comment. commentId: String! # The comment's content. content: String! # The comment timestamp. This field is indexed to enable sorted pagination. createdAt: String! # The id of the comment's parent event. eventId: ID! } type CommentConnection { items: [Comment] nextToken: String } type Event { # Paginate through all comments belonging to an individual post. comments(limit: Int, nextToken: String): CommentConnection description: String id: ID! name: String when: String where: String } type EventConnection { items: [Event] nextToken: String } type Mutation { # Comment on an event. commentOnEvent(content: String!, createdAt: String!, eventId: ID!): Comment # Create a single event. createEvent(description: String!, name: String!, when: String!, where: String!): Event # Delete a single event by id. deleteEvent(id: ID!): Event } type Query { # Get a single event by id. getEvent(id: ID!): Event # Paginate through events. listEvents(filter: TableEventFilterInput, limit: Int, nextToken: String): EventConnection } type Subscription { subscribeToEventComments(eventId: String!): Comment } input TableBooleanFilterInput { eq: Boolean ne: Boolean } input TableEventFilterInput { description: TableStringFilterInput id: TableIDFilterInput name: TableStringFilterInput when: TableStringFilterInput where: TableStringFilterInput } input TableFloatFilterInput { between: [Float] contains: Float eq: Float ge: Float gt: Float le: Float lt: Float ne: Float notContains: Float } input TableIDFilterInput { beginsWith: ID between: [ID] contains: ID eq: ID ge: ID gt: ID le: ID lt: ID ne: ID notContains: ID } input TableIntFilterInput { between: [Int] contains: Int eq: Int ge: Int gt: Int le: Int lt: Int ne: Int notContains: Int } input TableStringFilterInput { beginsWith: String between: [String] contains: String eq: String ge: String gt: String le: String lt: String ne: String notContains: String }
Documents(クエリ、ミューテーション、サブスクリプションのリクエスト)が無いとReact Custom Hookの生成は行えないので、SchemaからDocumentsを生成します。パッケージ名からわかるようにAmplify関連のパッケージなのですが、マルチパッケージで開発されているのでピンポイントで欲しい部分だけ使えて助かります。
生成されるDocumentsはレスポンスに全ての項目を要求する為、必要な項目だけ取得できるというGraphQLのメリットを殺してしまいます。これを嫌う場合は、手動でDocumentsを作成してください。
yarn add -D amplify-graphql-docs-generator@2.1.13 yarn amplify-graphql-docs-generator --schema src/graphql/schema.graphql --output src/graphql/all-operations.graphql --language graphql
下記はSchemaから生成されたDocumentです。前述の通りレスポンスに全ての項目(nameやwhenなど)を要求しています。
# this is an auto generated file. This will be overwritten query GetEvent($id: ID!) { getEvent(id: $id) { comments { nextToken } description id name when where } } query ListEvents( $filter: TableEventFilterInput $limit: Int $nextToken: String ) { listEvents(filter: $filter, limit: $limit, nextToken: $nextToken) { items { description id name when where } nextToken } } mutation CommentOnEvent($content: String!, $createdAt: String!, $eventId: ID!) { commentOnEvent(content: $content, createdAt: $createdAt, eventId: $eventId) { commentId content createdAt eventId } } mutation CreateEvent( $description: String! $name: String! $when: String! $where: String! ) { createEvent( description: $description name: $name when: $when where: $where ) { comments { nextToken } description id name when where } } mutation DeleteEvent($id: ID!) { deleteEvent(id: $id) { comments { nextToken } description id name when where } } subscription SubscribeToEventComments($eventId: String!) { subscribeToEventComments(eventId: $eventId) { commentId content createdAt eventId } }
SchemaとDocumentから型とReact Custom Hookを生成します。
必要なツールをインストールします。
yarn add \ graphql@15.0.0 \ @apollo/react-common@3.1.4 \ @apollo/react-hooks@3.1.5 yarn add -D \ @graphql-codegen/cli@1.13.5 \ @graphql-codegen/typescript@1.13.5 \ @graphql-codegen/typescript-operations@1.13.5 \ @graphql-codegen/typescript-react-apollo@1.13.5
graphql-codegenの設定ファイルを作成します。 この設定に基づいて型やHookが生成されます、TypeScriptのEnum使いたくないので、enumsAsTypesを有効にしてTypesに変換しています。好みに合わせて、カスタマイズしたい場合は公式ドキュメントを確認してください。
overwrite: true schema: - './src/graphql/schema.graphql' documents: - './src/graphql/all-operations.graphql' generates: src/graphql/generated.tsx: plugins: - 'typescript' - 'typescript-operations' - 'typescript-react-apollo' config: withComponent: false withHooks: true withHOC: false enumsAsTypes: true
型とReact Custom Hooksを生成します。
yarn graphql-codegen --config codegen.yml
Schemaに変更があった場合に簡単に再生成ができるように、NPM Scriptsを書いておきます。これでyarn codegen
とコマンドを実行する事で再生成が行なえます。
..., "scripts": { ... "codegen:get-schema": "aws appsync get-introspection-schema --api-id ${APP_SYNC_API_ID} --format SDL --include-directives src/graphql/schema.graphql", "codegen:docsgen": "amplify-graphql-docs-generator --schema src/graphql/schema.graphql --output src/graphql/all-operations.graphql --language graphql", "codegen": "yarn codegen:get-schema && yarn codegen:docsgen && graphql-codegen --config codegen.yml" }, ...
下記が生成された型とReact Custom Hookです。
import gql from 'graphql-tag'; import * as ApolloReactCommon from '@apollo/react-common'; import * as ApolloReactHooks from '@apollo/react-hooks'; export type Maybe<T> = T | null; /** All built-in and custom scalars, mapped to their actual values */ export type Scalars = { ID: string; String: string; Boolean: boolean; Int: number; Float: number; }; export type Comment = { __typename?: 'Comment'; commentId: Scalars['String']; content: Scalars['String']; createdAt: Scalars['String']; eventId: Scalars['ID']; }; export type CommentConnection = { __typename?: 'CommentConnection'; items?: Maybe<Array<Maybe<Comment>>>; nextToken?: Maybe<Scalars['String']>; }; export type Event = { __typename?: 'Event'; comments?: Maybe<CommentConnection>; description?: Maybe<Scalars['String']>; id: Scalars['ID']; name?: Maybe<Scalars['String']>; when?: Maybe<Scalars['String']>; where?: Maybe<Scalars['String']>; }; export type EventCommentsArgs = { limit?: Maybe<Scalars['Int']>; nextToken?: Maybe<Scalars['String']>; }; export type EventConnection = { __typename?: 'EventConnection'; items?: Maybe<Array<Maybe<Event>>>; nextToken?: Maybe<Scalars['String']>; }; export type Mutation = { __typename?: 'Mutation'; commentOnEvent?: Maybe<Comment>; createEvent?: Maybe<Event>; deleteEvent?: Maybe<Event>; }; export type MutationCommentOnEventArgs = { content: Scalars['String']; createdAt: Scalars['String']; eventId: Scalars['ID']; }; export type MutationCreateEventArgs = { description: Scalars['String']; name: Scalars['String']; when: Scalars['String']; where: Scalars['String']; }; export type MutationDeleteEventArgs = { id: Scalars['ID']; }; export type Query = { __typename?: 'Query'; getEvent?: Maybe<Event>; listEvents?: Maybe<EventConnection>; }; export type QueryGetEventArgs = { id: Scalars['ID']; }; export type QueryListEventsArgs = { filter?: Maybe<TableEventFilterInput>; limit?: Maybe<Scalars['Int']>; nextToken?: Maybe<Scalars['String']>; }; export type Subscription = { __typename?: 'Subscription'; subscribeToEventComments?: Maybe<Comment>; }; export type SubscriptionSubscribeToEventCommentsArgs = { eventId: Scalars['String']; }; export type TableBooleanFilterInput = { eq?: Maybe<Scalars['Boolean']>; ne?: Maybe<Scalars['Boolean']>; }; export type TableEventFilterInput = { description?: Maybe<TableStringFilterInput>; id?: Maybe<TableIdFilterInput>; name?: Maybe<TableStringFilterInput>; when?: Maybe<TableStringFilterInput>; where?: Maybe<TableStringFilterInput>; }; export type TableFloatFilterInput = { between?: Maybe<Array<Maybe<Scalars['Float']>>>; contains?: Maybe<Scalars['Float']>; eq?: Maybe<Scalars['Float']>; ge?: Maybe<Scalars['Float']>; gt?: Maybe<Scalars['Float']>; le?: Maybe<Scalars['Float']>; lt?: Maybe<Scalars['Float']>; ne?: Maybe<Scalars['Float']>; notContains?: Maybe<Scalars['Float']>; }; export type TableIdFilterInput = { beginsWith?: Maybe<Scalars['ID']>; between?: Maybe<Array<Maybe<Scalars['ID']>>>; contains?: Maybe<Scalars['ID']>; eq?: Maybe<Scalars['ID']>; ge?: Maybe<Scalars['ID']>; gt?: Maybe<Scalars['ID']>; le?: Maybe<Scalars['ID']>; lt?: Maybe<Scalars['ID']>; ne?: Maybe<Scalars['ID']>; notContains?: Maybe<Scalars['ID']>; }; export type TableIntFilterInput = { between?: Maybe<Array<Maybe<Scalars['Int']>>>; contains?: Maybe<Scalars['Int']>; eq?: Maybe<Scalars['Int']>; ge?: Maybe<Scalars['Int']>; gt?: Maybe<Scalars['Int']>; le?: Maybe<Scalars['Int']>; lt?: Maybe<Scalars['Int']>; ne?: Maybe<Scalars['Int']>; notContains?: Maybe<Scalars['Int']>; }; export type TableStringFilterInput = { beginsWith?: Maybe<Scalars['String']>; between?: Maybe<Array<Maybe<Scalars['String']>>>; contains?: Maybe<Scalars['String']>; eq?: Maybe<Scalars['String']>; ge?: Maybe<Scalars['String']>; gt?: Maybe<Scalars['String']>; le?: Maybe<Scalars['String']>; lt?: Maybe<Scalars['String']>; ne?: Maybe<Scalars['String']>; notContains?: Maybe<Scalars['String']>; }; export type GetEventQueryVariables = { id: Scalars['ID']; }; export type GetEventQuery = ( { __typename?: 'Query' } & { getEvent?: Maybe<( { __typename?: 'Event' } & Pick<Event, 'description' | 'id' | 'name' | 'when' | 'where'> & { comments?: Maybe<( { __typename?: 'CommentConnection' } & Pick<CommentConnection, 'nextToken'> )> } )> } ); export type ListEventsQueryVariables = { filter?: Maybe<TableEventFilterInput>; limit?: Maybe<Scalars['Int']>; nextToken?: Maybe<Scalars['String']>; }; export type ListEventsQuery = ( { __typename?: 'Query' } & { listEvents?: Maybe<( { __typename?: 'EventConnection' } & Pick<EventConnection, 'nextToken'> & { items?: Maybe<Array<Maybe<( { __typename?: 'Event' } & Pick<Event, 'description' | 'id' | 'name' | 'when' | 'where'> )>>> } )> } ); export type CommentOnEventMutationVariables = { content: Scalars['String']; createdAt: Scalars['String']; eventId: Scalars['ID']; }; export type CommentOnEventMutation = ( { __typename?: 'Mutation' } & { commentOnEvent?: Maybe<( { __typename?: 'Comment' } & Pick<Comment, 'commentId' | 'content' | 'createdAt' | 'eventId'> )> } ); export type CreateEventMutationVariables = { description: Scalars['String']; name: Scalars['String']; when: Scalars['String']; where: Scalars['String']; }; export type CreateEventMutation = ( { __typename?: 'Mutation' } & { createEvent?: Maybe<( { __typename?: 'Event' } & Pick<Event, 'description' | 'id' | 'name' | 'when' | 'where'> & { comments?: Maybe<( { __typename?: 'CommentConnection' } & Pick<CommentConnection, 'nextToken'> )> } )> } ); export type DeleteEventMutationVariables = { id: Scalars['ID']; }; export type DeleteEventMutation = ( { __typename?: 'Mutation' } & { deleteEvent?: Maybe<( { __typename?: 'Event' } & Pick<Event, 'description' | 'id' | 'name' | 'when' | 'where'> & { comments?: Maybe<( { __typename?: 'CommentConnection' } & Pick<CommentConnection, 'nextToken'> )> } )> } ); export type SubscribeToEventCommentsSubscriptionVariables = { eventId: Scalars['String']; }; export type SubscribeToEventCommentsSubscription = ( { __typename?: 'Subscription' } & { subscribeToEventComments?: Maybe<( { __typename?: 'Comment' } & Pick<Comment, 'commentId' | 'content' | 'createdAt' | 'eventId'> )> } ); export const GetEventDocument = gql` query GetEvent($id: ID!) { getEvent(id: $id) { comments { nextToken } description id name when where } } `; /** * __useGetEventQuery__ * * To run a query within a React component, call `useGetEventQuery` and pass it any options that fit your needs. * When your component renders, `useGetEventQuery` returns an object from Apollo Client that contains loading, error, and data properties * you can use to render your UI. * * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * * @example * const { data, loading, error } = useGetEventQuery({ * variables: { * id: // value for 'id' * }, * }); */ export function useGetEventQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<GetEventQuery, GetEventQueryVariables>) { return ApolloReactHooks.useQuery<GetEventQuery, GetEventQueryVariables>(GetEventDocument, baseOptions); } export function useGetEventLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<GetEventQuery, GetEventQueryVariables>) { return ApolloReactHooks.useLazyQuery<GetEventQuery, GetEventQueryVariables>(GetEventDocument, baseOptions); } export type GetEventQueryHookResult = ReturnType<typeof useGetEventQuery>; export type GetEventLazyQueryHookResult = ReturnType<typeof useGetEventLazyQuery>; export type GetEventQueryResult = ApolloReactCommon.QueryResult<GetEventQuery, GetEventQueryVariables>; export const ListEventsDocument = gql` query ListEvents($filter: TableEventFilterInput, $limit: Int, $nextToken: String) { listEvents(filter: $filter, limit: $limit, nextToken: $nextToken) { items { description id name when where } nextToken } } `; /** * __useListEventsQuery__ * * To run a query within a React component, call `useListEventsQuery` and pass it any options that fit your needs. * When your component renders, `useListEventsQuery` returns an object from Apollo Client that contains loading, error, and data properties * you can use to render your UI. * * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * * @example * const { data, loading, error } = useListEventsQuery({ * variables: { * filter: // value for 'filter' * limit: // value for 'limit' * nextToken: // value for 'nextToken' * }, * }); */ export function useListEventsQuery(baseOptions?: ApolloReactHooks.QueryHookOptions<ListEventsQuery, ListEventsQueryVariables>) { return ApolloReactHooks.useQuery<ListEventsQuery, ListEventsQueryVariables>(ListEventsDocument, baseOptions); } export function useListEventsLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<ListEventsQuery, ListEventsQueryVariables>) { return ApolloReactHooks.useLazyQuery<ListEventsQuery, ListEventsQueryVariables>(ListEventsDocument, baseOptions); } export type ListEventsQueryHookResult = ReturnType<typeof useListEventsQuery>; export type ListEventsLazyQueryHookResult = ReturnType<typeof useListEventsLazyQuery>; export type ListEventsQueryResult = ApolloReactCommon.QueryResult<ListEventsQuery, ListEventsQueryVariables>; export const CommentOnEventDocument = gql` mutation CommentOnEvent($content: String!, $createdAt: String!, $eventId: ID!) { commentOnEvent(content: $content, createdAt: $createdAt, eventId: $eventId) { commentId content createdAt eventId } } `; export type CommentOnEventMutationFn = ApolloReactCommon.MutationFunction<CommentOnEventMutation, CommentOnEventMutationVariables>; /** * __useCommentOnEventMutation__ * * To run a mutation, you first call `useCommentOnEventMutation` within a React component and pass it any options that fit your needs. * When your component renders, `useCommentOnEventMutation` returns a tuple that includes: * - A mutate function that you can call at any time to execute the mutation * - An object with fields that represent the current status of the mutation's execution * * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; * * @example * const [commentOnEventMutation, { data, loading, error }] = useCommentOnEventMutation({ * variables: { * content: // value for 'content' * createdAt: // value for 'createdAt' * eventId: // value for 'eventId' * }, * }); */ export function useCommentOnEventMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<CommentOnEventMutation, CommentOnEventMutationVariables>) { return ApolloReactHooks.useMutation<CommentOnEventMutation, CommentOnEventMutationVariables>(CommentOnEventDocument, baseOptions); } export type CommentOnEventMutationHookResult = ReturnType<typeof useCommentOnEventMutation>; export type CommentOnEventMutationResult = ApolloReactCommon.MutationResult<CommentOnEventMutation>; export type CommentOnEventMutationOptions = ApolloReactCommon.BaseMutationOptions<CommentOnEventMutation, CommentOnEventMutationVariables>; export const CreateEventDocument = gql` mutation CreateEvent($description: String!, $name: String!, $when: String!, $where: String!) { createEvent(description: $description, name: $name, when: $when, where: $where) { comments { nextToken } description id name when where } } `; export type CreateEventMutationFn = ApolloReactCommon.MutationFunction<CreateEventMutation, CreateEventMutationVariables>; /** * __useCreateEventMutation__ * * To run a mutation, you first call `useCreateEventMutation` within a React component and pass it any options that fit your needs. * When your component renders, `useCreateEventMutation` returns a tuple that includes: * - A mutate function that you can call at any time to execute the mutation * - An object with fields that represent the current status of the mutation's execution * * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; * * @example * const [createEventMutation, { data, loading, error }] = useCreateEventMutation({ * variables: { * description: // value for 'description' * name: // value for 'name' * when: // value for 'when' * where: // value for 'where' * }, * }); */ export function useCreateEventMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<CreateEventMutation, CreateEventMutationVariables>) { return ApolloReactHooks.useMutation<CreateEventMutation, CreateEventMutationVariables>(CreateEventDocument, baseOptions); } export type CreateEventMutationHookResult = ReturnType<typeof useCreateEventMutation>; export type CreateEventMutationResult = ApolloReactCommon.MutationResult<CreateEventMutation>; export type CreateEventMutationOptions = ApolloReactCommon.BaseMutationOptions<CreateEventMutation, CreateEventMutationVariables>; export const DeleteEventDocument = gql` mutation DeleteEvent($id: ID!) { deleteEvent(id: $id) { comments { nextToken } description id name when where } } `; export type DeleteEventMutationFn = ApolloReactCommon.MutationFunction<DeleteEventMutation, DeleteEventMutationVariables>; /** * __useDeleteEventMutation__ * * To run a mutation, you first call `useDeleteEventMutation` within a React component and pass it any options that fit your needs. * When your component renders, `useDeleteEventMutation` returns a tuple that includes: * - A mutate function that you can call at any time to execute the mutation * - An object with fields that represent the current status of the mutation's execution * * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; * * @example * const [deleteEventMutation, { data, loading, error }] = useDeleteEventMutation({ * variables: { * id: // value for 'id' * }, * }); */ export function useDeleteEventMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DeleteEventMutation, DeleteEventMutationVariables>) { return ApolloReactHooks.useMutation<DeleteEventMutation, DeleteEventMutationVariables>(DeleteEventDocument, baseOptions); } export type DeleteEventMutationHookResult = ReturnType<typeof useDeleteEventMutation>; export type DeleteEventMutationResult = ApolloReactCommon.MutationResult<DeleteEventMutation>; export type DeleteEventMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteEventMutation, DeleteEventMutationVariables>; export const SubscribeToEventCommentsDocument = gql` subscription SubscribeToEventComments($eventId: String!) { subscribeToEventComments(eventId: $eventId) { commentId content createdAt eventId } } `; /** * __useSubscribeToEventCommentsSubscription__ * * To run a query within a React component, call `useSubscribeToEventCommentsSubscription` and pass it any options that fit your needs. * When your component renders, `useSubscribeToEventCommentsSubscription` returns an object from Apollo Client that contains loading, error, and data properties * you can use to render your UI. * * @param baseOptions options that will be passed into the subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * * @example * const { data, loading, error } = useSubscribeToEventCommentsSubscription({ * variables: { * eventId: // value for 'eventId' * }, * }); */ export function useSubscribeToEventCommentsSubscription(baseOptions?: ApolloReactHooks.SubscriptionHookOptions<SubscribeToEventCommentsSubscription, SubscribeToEventCommentsSubscriptionVariables>) { return ApolloReactHooks.useSubscription<SubscribeToEventCommentsSubscription, SubscribeToEventCommentsSubscriptionVariables>(SubscribeToEventCommentsDocument, baseOptions); } export type SubscribeToEventCommentsSubscriptionHookResult = ReturnType<typeof useSubscribeToEventCommentsSubscription>; export type SubscribeToEventCommentsSubscriptionResult = ApolloReactCommon.SubscriptionResult<SubscribeToEventCommentsSubscription>;
各クエリー、ミューテーション、サブスクリプションに対するApollo ClientのReact Custom Hookが生成されました。Apollo Clientはかなりインテリジェントでfetch more処理などが実装済みです。初めてApollo Clientに触った時は感動しました。
この様にフロントとバックで型を共有し、さらにAPIアクセス部分のコードを自動生成できる事は、AppSync(GraphQL)使う非常に大きなメリットです。
サンプルアプリ(React)の構築 パート4
パート4ではAuth0からApollo ClientへTokenの受け渡しと、パート3で生成されたReact Custom Hookを使って、ラフにですがCRUD操作を実装します。
必要なパッケージをインストールします。
yarn add \ apollo-client@2.6.8 \ apollo-cache-inmemory@1.6.5 \ apollo-link-context@1.0.20 \ apollo-link-http@1.5.17 \ react-apollo@3.1.5
Auth0とAppSyncの設定をアプリケーションが取り込めるようにJSONファイルを更新&作成します。
auth-config.json
にaudience
を追加します。パート1の最初で紹介したチュートリアル通りなappsync-auth0がaudience
です。
{ "domain": "YOUR_DOMAIN", "clientId": "YOUR_CLIENT_ID" "audience": "YOUR_AUDIENCE" }
app-sync-config.json
を作成し、uri
にAppSyncのエンドポイントを入力します。
{ "uri": "YOUR_API_URL" }
Auth0からTokenを受け取り、authorization
ヘッダーにセットするApolloProvider
のWrapper、AuthorizedApolloProvider
を作成します。
import React from 'react'; import { useAuth0 } from './react-auth0-spa'; import { HttpLink } from 'apollo-link-http'; import appSyncConfig from './app-sync-config.json'; import { setContext } from 'apollo-link-context'; import { ApolloLink } from 'apollo-link'; import { InMemoryCache } from 'apollo-cache-inmemory'; import { ApolloClient } from 'apollo-client'; import { ApolloProvider } from '@apollo/react-hooks'; export const AuthorizedApolloProvider: React.FC = ({children}) => { const [token, setToken] = React.useState<string>(''); const {loading, getTokenSilently} = useAuth0(); if (loading) { return <h1>Loading...</h1>; } const httpLink = new HttpLink({ uri: appSyncConfig.uri, fetchOptions: {credentials: 'same-origin'} }); const withTokenLink = setContext(async () => { if (token) { return {auth0Token: token}; } const newToken = await getTokenSilently(); setToken(newToken); return {auth0Token: newToken}; }); const authLink = setContext((_, {headers, auth0Token}) => ({ headers: { ...headers, ...(auth0Token ? {authorization: auth0Token} : {}) } })); const client = new ApolloClient({ link: ApolloLink.from([withTokenLink, authLink, httpLink]), cache: new InMemoryCache() }); return ( <ApolloProvider client={client}> {children} </ApolloProvider> ); };
作成したAuthorizedApolloProvider
をアプリケーションに統合します。合わせて、Auth0Provider
にaudience
を設定します。
import React from 'react'; import ReactDOM from 'react-dom'; import { App } from './App'; import * as serviceWorker from './serviceWorker'; import { history } from './utils/history'; import { Auth0Provider } from './react-auth0-spa'; import authConfig from './auth-config.json'; import { AuthorizedApolloProvider } from './authorized-apollo-client'; const onRedirectCallback = async (url?: string) => { history.push(url ?? window.location.pathname); }; ReactDOM.render( <React.StrictMode> <Auth0Provider domain={authConfig.domain} client_id={authConfig.clientId} redirect_uri={window.location.origin} audience={authConfig.audience} onRedirectCallback={onRedirectCallback} > <AuthorizedApolloProvider> <App /> </AuthorizedApolloProvider> </Auth0Provider> </React.StrictMode>, document.getElementById('root') ); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister();
Eventsに対してCRUDするコンポーネントを作成します。
import React from 'react'; import { useCreateEventMutation, useDeleteEventMutation, useListEventsQuery } from '../graphql/generated'; const limit = 100; export const DemoTable: React.FC = () => { const { data, refetch } = useListEventsQuery({variables: {limit}}); const [ addEvent ] = useCreateEventMutation(); const [ deleteData ] = useDeleteEventMutation(); const handleCreateClick = async () => { await addEvent({ variables: { name: "My First Event", when: "Today", where: "My House", description: "Very first event", } }) // FIXME: リフェッチせずにキャッシュを書き換えるべき await refetch(); } const handleDeleteClick = async (id?: string) => { if (id == null) { return } await deleteData({variables: {id}}) // FIXME: リフェッチせずにキャッシュを書き換えるべき await refetch(); } return ( <> <h1>Events 100件まで表示</h1> <button onClick={() => {handleCreateClick()}}>作成する</button> <table> <thead> <tr> <th>ID</th> <th>Name</th> <th>Description</th> <th>When</th> <th>Where</th> </tr> </thead> <tbody> { data?.listEvents?.items?.map(value => ( <tr key={value?.id}> <td>{value?.id}</td> <td>{value?.name}</td> <td>{value?.description}</td> <td>{value?.when}</td> <td>{value?.where}</td> <td><button onClick={() => {handleDeleteClick(value?.id)}}>削除する</button></td> </tr> )) } </tbody> </table> </> ); };
NavBar.tsx
にDemoTable
へのリンクを設定します。
import React from 'react'; import { useAuth0 } from '../react-auth0-spa'; import { Link } from 'react-router-dom'; export const NavBar = () => { const {isAuthenticated, loginWithRedirect, logout} = useAuth0(); return ( <div> {!isAuthenticated && ( <button onClick={() => loginWithRedirect()}>Log in</button> )} {isAuthenticated && ( <> <button onClick={() => logout()}>Log out</button> <span> <Link to="/">Home</Link> | <Link to="/profile">Profile</Link> | <Link to="/demo-table">Demo Table</Link> </span> </> )} </div> ); };
App.tsx
にDemoTable画面へのルーティングを設定します。
import React from "react"; import { NavBar } from "./components/NavBar"; import { Router, Route, Switch } from "react-router-dom"; import { Profile } from "./components/Profile"; import { DemoTable } from "./components/DemoTable"; import { history } from "./utils/history"; import { PrivateRoute } from './components/PrivateRoute'; export const App = () => { return ( <div className="App"> <Router history={history}> <header> <NavBar /> </header> <Switch> <Route path="/" exact /> <PrivateRoute path="/profile" component={Profile} /> <PrivateRoute path="/demo-table" component={DemoTable} /> </Switch> </Router> </div> ); };
これで完成です!!
Demo Tableページを開きCRUD操作が行えるか確認します。無事に作成できれば完了です!
追記・修正
コメントを貰い、以下の修正修正をしました。
あとがき
きちんと手順をまとめてブログにできる状態まで持っていこうとしたら、1日半かかりました。。。私がReact Contextを使うのが初めてだったのも、時間がかかった理由の一つです。元々は必要な情報をまとめるだけのつもりだったけど、いい感じにまとめられそうだったのでチュートリアルにしました。
ハマったところ・辛かったところ
- Amplifyを使わずにAppSyncを使う方法の情報が全然ない
- パート3の部分、AWS CLIを使うやや変則的な方法だが無事にブログ化できて良かった
- Auth0のReactチュートリアルがJavaScriptしかない
- TypeScriptも用意して欲しい。。。新しいことにトライしながら同時にJS→TS変換するの辛い
- というかuseAuth0のReact Custom Hook公開して欲しい
React & AppSync(GraphQL) & Apollo Clientという今後も使う機会がありそうな構成をまとめられて満足です。しかし、Apollo Clientの3.0がまもなくリリースされるのでこのチュートリアルの賞味期限は早そうです。
以上でした!
付録(なんでAWS Cognito UserPoolじゃなくてAuth0を使うの?)
AWSでエンドユーザー向けの認証といえば、AWS Cognito UserPool(以降、Cognito UP)が有名です。AWSのサービスなので他サービスとの親和性も高く活用する事で、すぐにアプリケーションに認証を追加する事ができます。
ただし、これは単一のアプリケーションに大して認証を提供する場合で、全社のアプリケーションにシングル・サインオンを提供しようとするとCognito UPだと厳しいというのが私の見解です。正確にはCognito UPだけでは厳しいで、Cognito UPをバックエンドとするサーバーサイドアプリケーションを自前で構築すれば可能でしょう。
これはCognito UPのHosted UIのカスタムマイズが非常に限定的である事、Token EndpointがRefresh Tokenを返せない事、一般的なAWSアカウント分割の方針と相性が悪い事などが理由です。
こういう場合に取れる選択肢は、AWS外のSaaSを使うか、自前で構築するかです。そして、前者としてAuth0は有名なサービスの1つです。
参考元
- Auth0 React SDK Quickstarts: Login
- ベースにしたAuth0公式のReact用チュートリアル
- @auth0/auth0-spa-js | @auth0/auth0-spa-js
- SDKの仕様を確認した
- Export TypeScript types · Issue #39 · auth0/auth0-spa-js · GitHub
- Auth0のCustomHookのTypeScript化の参考にした
- get-introspection-schema — AWS CLI 1.18.53 Command Reference
- AppSyncからスキーマをダウンロードするAWS CLIコマンド
- Use amplify codegen without setting up amplify project #247
- Amplify ConsoleのセットアップをせずにGraphQL Documents生成をしたいと思い調べていたら、生成機能単体でパッケージとして公開せれていた
- How to use react-auth0-spa with GraphQL? - Auth0 Community
- Apollo ClientとAuth0の連携の参考にした